Анализ поведения пользователей на основе логов в приложении он-лайн магазина, A-/A/B-тесты предложенных изменений.¶

Требуется проанализировать поведение пользователей мобильного приложения магазина он-лайн продаж продуктов питания.

Ввводные данные:

  • доступны данные логов пользователей за определенный период
  • в логах содержатся разные события - посещение той или иной страницы приложения
  • нужен тест на эффективность/целесообразность изменения шрифтов в приложении: для этого пользователи разбиты на 2 тестовые и экспериментальную группы. В 2 контрольных группах пользователи используют приложение со старыми шрифтами, экспериментальная группа - видит в приложении новые. Предстоит выяснить, целесообразно ли изменение шрифта.

Порядок проведения анализа:

  • считаем данные, изучим их на предмет наличия пропусков, дубликатов, при необходимости поменяем формат данных
  • сформируем и изучим воронку продаж, как пользователи распределяются от этапа к этапу
  • изучим корректность формирования 2 тестовых групп и поведение пользователей в них, для чего проведем А/А-тест для выявления наличия / отсутствия различия в поведении пользователей на разных этапах (ожидаем, что если сэмплы сформированы корректно, то разницы быть не должно)
  • для тестовых и экспериментальной групп проведем А/В-тесты для выявления наличия различий в поведении пользователей разных групп.
  • проанализируем последствия множественных тестов, при необходимости скорректируем расчеты.
In [1]:
#импортируем нужные библиотеки
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
import plotly.express as px  
import scipy.stats as stats
from statsmodels.stats.proportion import proportions_ztest

Загрузка данных¶

Будем работать с данными логов пользователей за определенный период, каждая запись в логе — это действие пользователя или событие. Контрольные группы - 246 и 247, экспериментальная - 248; DeviceIDHash - уникальный id пользователя

In [2]:
df = pd.read_csv('C:/Users/okald/datasets/logs_exp.csv', sep='\t')
df.head()
Out[2]:
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
In [3]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB

Считали данные, далее приведем их к удобному для работы формату.

Подготовка данных к анализу¶

In [4]:
#Изменим формат данных в колонке EventTimestamp
df['date_time'] = pd.to_datetime(df['EventTimestamp'], unit='s')
In [5]:
# создадим отдельную колонку только с датой события
df['date'] = df['date_time'].dt.floor('D')
In [6]:
df.head()
Out[6]:
EventName DeviceIDHash EventTimestamp ExpId date_time date
0 MainScreenAppear 4575588528974610257 1564029816 246 2019-07-25 04:43:36 2019-07-25
1 MainScreenAppear 7416695313311560658 1564053102 246 2019-07-25 11:11:42 2019-07-25
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
3 CartScreenAppear 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248 2019-07-25 11:48:42 2019-07-25
In [7]:
#пропущенные значения отсутствуют
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 6 columns):
 #   Column          Non-Null Count   Dtype         
---  ------          --------------   -----         
 0   EventName       244126 non-null  object        
 1   DeviceIDHash    244126 non-null  int64         
 2   EventTimestamp  244126 non-null  int64         
 3   ExpId           244126 non-null  int64         
 4   date_time       244126 non-null  datetime64[ns]
 5   date            244126 non-null  datetime64[ns]
dtypes: datetime64[ns](2), int64(3), object(1)
memory usage: 11.2+ MB
In [8]:
# проверим данные на наличие полных дубликатов - их, как выясняется, 413
df.duplicated().sum()
Out[8]:
413
In [9]:
#удаляем полные дубликаты
df = df.drop_duplicates().reset_index(drop=True)
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 243713 entries, 0 to 243712
Data columns (total 6 columns):
 #   Column          Non-Null Count   Dtype         
---  ------          --------------   -----         
 0   EventName       243713 non-null  object        
 1   DeviceIDHash    243713 non-null  int64         
 2   EventTimestamp  243713 non-null  int64         
 3   ExpId           243713 non-null  int64         
 4   date_time       243713 non-null  datetime64[ns]
 5   date            243713 non-null  datetime64[ns]
dtypes: datetime64[ns](2), int64(3), object(1)
memory usage: 11.2+ MB
In [10]:
# проверим распределение пользователей между группами - ПЕРЕСЕЧЕНИЙ ПОЛЬЗОВАТЕЛЕЙ НЕТ 
intersections = df.pivot_table(index='DeviceIDHash', values='ExpId', aggfunc='nunique')\
                .sort_values(by='ExpId', ascending=False)
intersections[intersections['ExpId']>1].count()
Out[10]:
ExpId    0
dtype: int64

Таким образом, на данном этапе данные были приведены к удобному для дальнейшей работы формату, очищены от дубликатов.

Исследовательский анализ данных¶

Общее кол-во событий во всем датафрейме и по типам событий (логов)¶

In [11]:
# всего строк в датафрейме
display(df.shape[0])
df.EventName.count()
243713
Out[11]:
243713
In [12]:
# разбивка всех записей по типам событий
df.groupby('EventName').agg({'DeviceIDHash':'count'})\
                       .sort_values('DeviceIDHash', ascending = False)
Out[12]:
DeviceIDHash
EventName
MainScreenAppear 119101
OffersScreenAppear 46808
CartScreenAppear 42668
PaymentScreenSuccessful 34118
Tutorial 1018

Уникальное кол-во пользователей¶

In [13]:
df.DeviceIDHash.nunique()
Out[13]:
7551

Среднее кол-во событий на каждого пользователя¶

In [14]:
# посмотрим на распределение всех событий по пользователям
grouped_by_user = df.groupby('DeviceIDHash')\
                    .agg({'EventName': 'count'})\
                    .sort_values(by = 'EventName', ascending = False)
grouped_by_user 
Out[14]:
EventName
DeviceIDHash
6304868067479728361 2307
197027893265565660 1998
4623191541214045580 1768
6932517045703054087 1439
1754140665440434215 1221
... ...
7399061063341528729 1
2968164493349205501 1
8071397669512236988 1
425817683219936619 1
6888746892508752 1

7551 rows × 1 columns

In [15]:
# Считаем среднее кол-во значений на каждого пользователя
display(df.EventName.count() / df.DeviceIDHash.nunique())
grouped_by_user.mean()
32.27559263673685
Out[15]:
EventName    32.275593
dtype: float64

Т.е. в среднем 32 события на пользователя, при этом данные варьируются в диапазоне 1-2307 событий на пользователя

Временной диапазон данных¶

In [16]:
# Считаем дату начала и окончания эксперимента (2 недели)
display(df['date'].min())
df['date'].max()
Timestamp('2019-07-25 00:00:00')
Out[16]:
Timestamp('2019-08-07 00:00:00')
In [17]:
# Смотрим на количество событий за каждый день
df_timerange = df.groupby('date')\
                 .agg({'EventTimestamp':'count'}).reset_index()
df_timerange
Out[17]:
date EventTimestamp
0 2019-07-25 9
1 2019-07-26 31
2 2019-07-27 55
3 2019-07-28 105
4 2019-07-29 184
5 2019-07-30 412
6 2019-07-31 2030
7 2019-08-01 36141
8 2019-08-02 35554
9 2019-08-03 33282
10 2019-08-04 32968
11 2019-08-05 36058
12 2019-08-06 35788
13 2019-08-07 31096
In [18]:
#Строим график динамики кол-ва сообытий по дням эксперимента
plt.figure(figsize=(13,6));
plt.plot(df_timerange['date'], df_timerange['EventTimestamp']);
plt.xticks(df_timerange['date'], rotation=45);
plt.ylabel('кол-во событий в день');
plt.title('Динамика количества событий по дням', fontsize = 16); 
plt.grid(True, color = "grey", linewidth = "0.5");

Судя по графику, данные за июль 2019 не полные. Чтобы не исказить результаты исследования, предлагается оставить для анализа только имеющиеся данные за первую неделю августа (01/08/2019-07/08/2019)

In [19]:
#Создаем новый массив только с данными за нужный период 
df_actual= df.query('date>="2019-08-01"').reset_index()
df_actual = df_actual.iloc[:, 1:7]
df_actual.head()
Out[19]:
EventName DeviceIDHash EventTimestamp ExpId date_time date
0 Tutorial 3737462046622621720 1564618048 246 2019-08-01 00:07:28 2019-08-01
1 MainScreenAppear 3737462046622621720 1564618080 246 2019-08-01 00:08:00 2019-08-01
2 MainScreenAppear 3737462046622621720 1564618135 246 2019-08-01 00:08:55 2019-08-01
3 OffersScreenAppear 3737462046622621720 1564618138 246 2019-08-01 00:08:58 2019-08-01
4 MainScreenAppear 1433840883824088890 1564618139 247 2019-08-01 00:08:59 2019-08-01
In [20]:
# Проверим, что ничего не потерялось
df_timerange_actual = df_actual.groupby('date')\
                               .agg({'EventTimestamp':'count'}).reset_index()
df_timerange_actual
Out[20]:
date EventTimestamp
0 2019-08-01 36141
1 2019-08-02 35554
2 2019-08-03 33282
3 2019-08-04 32968
4 2019-08-05 36058
5 2019-08-06 35788
6 2019-08-07 31096
In [21]:
# диапазон кол-ва дневных логов относительно узкий - 31-36 тыс. в день
plt.figure(figsize=(13,6));
plt.plot(df_timerange_actual['date'], df_timerange_actual['EventTimestamp']);
plt.xticks(df_timerange_actual['date'], rotation=45);
plt.ylabel('кол-во событий в день');
plt.title('Динамика количества событий по дням', fontsize = 16); 
plt.grid(True, color = "grey", linewidth = "0.5");
plt.ylim(30000, 37000);

Анализ кол-ва потерянных событий и пользователей, после исключения "старых" данных.¶

In [22]:
# Найдем число потерянных событий 
display(df['EventTimestamp'].count()- df_actual['EventTimestamp'].count())
df_timerange['EventTimestamp'].sum() - df_timerange_actual['EventTimestamp'].sum()
2826
Out[22]:
2826
In [23]:
#считаем долю потерянных событий
round(100-df_timerange_actual['EventTimestamp'].sum()/df_timerange['EventTimestamp'].sum()*100, 1)
Out[23]:
1.2

Потеряли 1,2% событий, что не так много

In [24]:
#считаем число потерянных пользователей
df.DeviceIDHash.nunique()- df_actual.DeviceIDHash.nunique()
Out[24]:
17
In [25]:
#считаем долю потерянных пользователей
round(((df.DeviceIDHash.nunique()- df_actual.DeviceIDHash.nunique()) / df.DeviceIDHash.nunique()*100), 2)
Out[25]:
0.23

Потеряли всего 17 уникальных пользователей из 7551, т.е. 0,23% от общего числа уникальных пользователей, - тоже не много

Проверка на наличие пользователей из всех трёх тестовых групп.¶

In [26]:
# смотрим на распределение событий и пользователей по группам 
df_actual.groupby('ExpId')\
         .agg({'DeviceIDHash': ['count', 'nunique']})
Out[26]:
DeviceIDHash
count nunique
ExpId
246 79302 2484
247 77022 2513
248 84563 2537

Пользователи есть в каждой из 3 групп, а их численность внутри групп сопоставима. Событий больше всего в экспериментальной 248 группе.

Таким образом, на данном этапе мы провели проверку данных на предмет наличия пропусков и дубликатов, определились с корректным для анализа периодом, привели данные к нужным форматам, где это было необходимо.

Воронка событий¶

Частота разных событий в логах¶

In [27]:
#Считаем количество действий пользователей в разрезе событий
events_by_type = df_actual.groupby('EventName')['DeviceIDHash'].count().reset_index()
events_by_type.columns = ['EventName', 'event_amount']
events_by_type = events_by_type.sort_values(['event_amount'], ascending = False)
events_by_type
Out[27]:
EventName event_amount
1 MainScreenAppear 117328
2 OffersScreenAppear 46333
0 CartScreenAppear 42303
3 PaymentScreenSuccessful 33918
4 Tutorial 1005

Распределение пользователей по событиям¶

In [28]:
# посмотрим на кол-во уникальных пользователей в разрезе событий
users_by_event = df_actual.groupby('EventName')['DeviceIDHash'].nunique().reset_index()
users_by_event.columns = ['EventName', 'unique_users']
users_by_event= users_by_event.sort_values(['unique_users'], ascending = False)
users_by_event
Out[28]:
EventName unique_users
1 MainScreenAppear 7419
2 OffersScreenAppear 4593
0 CartScreenAppear 3734
3 PaymentScreenSuccessful 3539
4 Tutorial 840
In [29]:
# объединим эти данные в единой таблице
df_actual.groupby('EventName')\
         .agg({'DeviceIDHash':['count', 'nunique']})\
         .sort_values(('DeviceIDHash', 'nunique'), ascending=False)
Out[29]:
DeviceIDHash
count nunique
EventName
MainScreenAppear 117328 7419
OffersScreenAppear 46333 4593
CartScreenAppear 42303 3734
PaymentScreenSuccessful 33918 3539
Tutorial 1005 840

Доля пользователей, которые хоть раз совершали событие¶

In [30]:
# всего уникальных пользователей
df_actual.DeviceIDHash.nunique()
Out[30]:
7534
In [31]:
users_by_event['%_users_with_this_event'] = round(users_by_event['unique_users']\
                                                  / df_actual.DeviceIDHash.nunique()*100, 1)
users_by_event
Out[31]:
EventName unique_users %_users_with_this_event
1 MainScreenAppear 7419 98.5
2 OffersScreenAppear 4593 61.0
0 CartScreenAppear 3734 49.6
3 PaymentScreenSuccessful 3539 47.0
4 Tutorial 840 11.1

Таким образом, этап MainScreenAppear есть у 98.5% уникальных пользователей (теоретически с учетом ограниченного для анализа недельного временного интервала можно предположить, что не все попавшие в выборку уникальные пользователи начинают пользоваться приложением начиная с главного экрана, возможно, что кто-то сразу смотрит в корзину (которая могла быть сформирована ранее). Также из рекламных источников пользователи могут попадать сразу в карточки товаров, обойдя главный экран. Этап PaymentScreenSuccessful - только у 47% пользователей.

Анализ цепочек событий¶

По ряду событий прослеживается четкая негативная динамика, что позволяет сделать предположение, что на каждом этапе отсеивается часть пользователей, которые уже не попадают на следующий этап. Исходя из этого можно предположить, что цепочка последовательных событий выглядит так:

  • MainScreenAppear
  • OffersScreenAppear
  • CartScreenAppear
  • PaymentScreenSuccessful

Этап Tutorial видимо не является обязательным для перехода на следующий этап и может появиться в любой период между событиями пользователя. События данного этапа имеет смысл исключить из анализа воронок, иначе у нас не получится цепочка однозначных переходов.

In [32]:
# исключим событие Tutorial из датасета, чтобы получить корректные воронки
funnels = df_actual[df_actual['EventName']!='Tutorial']\
                .groupby('EventName')['DeviceIDHash'].nunique().reset_index()\
                .sort_values('DeviceIDHash', ascending = False)
funnels.columns = ['EventName', 'num_unique_users']
funnels = funnels.reset_index()
funnels = funnels.iloc[:, 1:3]
funnels
Out[32]:
EventName num_unique_users
0 MainScreenAppear 7419
1 OffersScreenAppear 4593
2 CartScreenAppear 3734
3 PaymentScreenSuccessful 3539

Доля пользователей, переходящих на следующий шаг воронки (от числа пользователей предыдущего шага).¶

In [33]:
# создадим новую колонку для расчета % при переходах внутри воронки
funnels['share_%'] = 0
funnels
Out[33]:
EventName num_unique_users share_%
0 MainScreenAppear 7419 0
1 OffersScreenAppear 4593 0
2 CartScreenAppear 3734 0
3 PaymentScreenSuccessful 3539 0
In [34]:
# Считаем % пользователей, переходящих на следующий шаг воронки с предыдущего шага
funnels['share_%']=round(funnels["num_unique_users"] \
                         / funnels["num_unique_users"].shift(fill_value=7419)*100, 1)
funnels
Out[34]:
EventName num_unique_users share_%
0 MainScreenAppear 7419 100.0
1 OffersScreenAppear 4593 61.9
2 CartScreenAppear 3734 81.3
3 PaymentScreenSuccessful 3539 94.8
In [35]:
# Посчитаем % изменения кол-ва пользователей при переходе с шага на шаг
funnels['%_change']=round(funnels['num_unique_users'].pct_change()*100, 1)
funnels
Out[35]:
EventName num_unique_users share_% %_change
0 MainScreenAppear 7419 100.0 NaN
1 OffersScreenAppear 4593 61.9 -38.1
2 CartScreenAppear 3734 81.3 -18.7
3 PaymentScreenSuccessful 3539 94.8 -5.2
In [36]:
data = dict(
    number=funnels['num_unique_users'],
    stage= funnels['EventName'])
fig = px.funnel(data, x='number', y='stage', \
                title = "Изменение числа уникальных пользователей внутри воронки")
fig.show();

Таким образом, больше всего пользователей теряем на этапе OffersScreenAppear - до него доходит только 62% от пользователей, которые попали на этап MainScreenAppear (просмотр главной страницы).

Из тех пользователей, которые попали на этап OffersScreenAppear, уже 81% пользователей положат что-то в корзину.

А из тех кто положит что-то в корзину, 94% сделают покупку.

Доля пользователей, которая доходит от первого события до оплаты.¶

In [37]:
funnels['fin'] = '-'
In [38]:
pd.options.mode.chained_assignment = None
funnels['fin'][3]=round(funnels['num_unique_users'][3]/funnels['num_unique_users'][0]*100, 1)
funnels
Out[38]:
EventName num_unique_users share_% %_change fin
0 MainScreenAppear 7419 100.0 NaN -
1 OffersScreenAppear 4593 61.9 -38.1 -
2 CartScreenAppear 3734 81.3 -18.7 -
3 PaymentScreenSuccessful 3539 94.8 -5.2 47.7

Около 48% пользователей (вне зависимости от группы - тестовая / экспериментальная), доходят до этапа совершения покупки на сайте.

На данном этапе удалось сделать предположения о наиболее вероятной последовательности этапов использования приложения, выстроить пользовательскую воронку и оценить, как пользователи через нее проходят - на какой этап попадают почти все и как пользователи "отваливаются" внутри воронки.

Результаты эксперимента¶

Расчет количества пользователей в каждой группе.¶

In [39]:
#Чисто зрительно различий между группами по кол-ву логов и числу уникальных пользователей нет 
df_actual.groupby('ExpId')\
         .agg({'DeviceIDHash': ['count', 'nunique']})
Out[39]:
DeviceIDHash
count nunique
ExpId
246 79302 2484
247 77022 2513
248 84563 2537

А/А-эксперимент для 2 контрольных групп 246 и 247¶

Прежде чем тестировать группы на предмет статистически значимых различий в поведении их пользователей, посмотрим на динамику логов каждой из групп

In [40]:
#Считаем логи по дням
df_actual_246 = df_actual[df_actual['ExpId']==246]
grouped_df_clean_246 = df_actual_246.groupby('date')\
                                    .agg({'DeviceIDHash': ['count']}).reset_index()
grouped_df_clean_246.columns = ['date', 'logs_count_246'] 
grouped_df_clean_246
Out[40]:
date logs_count_246
0 2019-08-01 11561
1 2019-08-02 10946
2 2019-08-03 10575
3 2019-08-04 11514
4 2019-08-05 12368
5 2019-08-06 11726
6 2019-08-07 10612
In [41]:
# Считаем логи кумулятивно по датам
cumul_logs_246 = grouped_df_clean_246\
                .apply(lambda x: grouped_df_clean_246[grouped_df_clean_246['date'] <= x['date']]\
                .agg({'date' : 'max', 'logs_count_246' : 'sum'}), axis=1)\
                .sort_values(by=['date'])
cumul_logs_246
Out[41]:
date logs_count_246
0 2019-08-01 11561
1 2019-08-02 22507
2 2019-08-03 33082
3 2019-08-04 44596
4 2019-08-05 56964
5 2019-08-06 68690
6 2019-08-07 79302
In [42]:
# Объединим данные по логам за день с кумулятивными данными в единую таблицу
aggregated_246 = grouped_df_clean_246.merge(cumul_logs_246, on = 'date')
aggregated_246.columns = ['date', 'logs_count_246', 'cum_logs_count_246']
aggregated_246
Out[42]:
date logs_count_246 cum_logs_count_246
0 2019-08-01 11561 11561
1 2019-08-02 10946 22507
2 2019-08-03 10575 33082
3 2019-08-04 11514 44596
4 2019-08-05 12368 56964
5 2019-08-06 11726 68690
6 2019-08-07 10612 79302
In [43]:
# Делаем то же самое для группы 247
df_actual_247 = df_actual[df_actual['ExpId']==247]
grouped_df_clean_247 = df_actual_247.groupby('date')\
                                    .agg({'DeviceIDHash': ['count']}).reset_index()
grouped_df_clean_247.columns  = ['date', 'logs_count_247'] 

cumul_logs_247 = grouped_df_clean_247\
                .apply(lambda x: grouped_df_clean_247[grouped_df_clean_247['date'] <= x['date']]\
                .agg({'date' : 'max', 'logs_count_247': 'sum'}), axis=1)

aggregated_247 = grouped_df_clean_247.merge(cumul_logs_247, on = 'date')
aggregated_247.columns = ['date', 'logs_count_247', 'cum_logs_count_247']
aggregated_247
Out[43]:
date logs_count_247 cum_logs_count_247
0 2019-08-01 12306 12306
1 2019-08-02 10990 23296
2 2019-08-03 11024 34320
3 2019-08-04 9942 44262
4 2019-08-05 10949 55211
5 2019-08-06 11720 66931
6 2019-08-07 10091 77022
In [44]:
# Делаем то же самое для группы 248
df_actual_248 = df_actual[df_actual['ExpId']==248]
grouped_df_clean_248 = df_actual_248.groupby('date')\
                                    .agg({'DeviceIDHash': ['count']}).reset_index()
grouped_df_clean_248.columns = ['date', 'logs_count_248'] 

cumul_logs_248 = grouped_df_clean_248\
                .apply(lambda x: grouped_df_clean_248[grouped_df_clean_248['date'] <= x['date']]\
                .agg({'date' : 'max', 'logs_count_248': 'sum'}), axis=1)

aggregated_248 = grouped_df_clean_248.merge(cumul_logs_248, on = 'date')
aggregated_248.columns = ['date', 'logs_count_248', 'cum_logs_count_248']
aggregated_248
Out[44]:
date logs_count_248 cum_logs_count_248
0 2019-08-01 12274 12274
1 2019-08-02 13618 25892
2 2019-08-03 11683 37575
3 2019-08-04 11512 49087
4 2019-08-05 12741 61828
5 2019-08-06 12342 74170
6 2019-08-07 10393 84563
In [45]:
plt.figure(figsize=(13,6));
plt.plot(aggregated_246['date'], aggregated_246['cum_logs_count_246'], label='A')
plt.plot(aggregated_247['date'], aggregated_247['cum_logs_count_247'], label='A1')
plt.plot(aggregated_248['date'], aggregated_248['cum_logs_count_248'], label='B')
plt.legend();
plt.title('Графики кумулятивных логов по дням по группам'); 
plt.ylabel('Кол-во логов в день'); 

Каких-либо видимых аномалий не наблюдается. Исходя из графика различий по кол-ву логов между контрольными группам 246 и 247 не наблюдается, но вот у тестовой группы 248 кумулятивное кол-во логов кажется стабильно выше тестовых Групп. Возможно поведение пользователей в этой групе существенно отличается от 2 других групп.

Требуется проверить, находят ли статистические критерии разницу между контрольными выборками 246 и 247.

In [46]:
#Общее кол-во уникальных пользователей в каждой группе 
unique_users_in_246 = df_actual[df_actual['ExpId']==246]['DeviceIDHash'].nunique()
unique_users_in_247 = df_actual[df_actual['ExpId']==247]['DeviceIDHash'].nunique()
display(unique_users_in_246)
unique_users_in_247
2484
Out[46]:
2513
In [47]:
# Переходы пользователей по разным шагам воронки для Групп 246 и 247
grouped246 = df_actual[df_actual['ExpId']==246].groupby('EventName')['DeviceIDHash']\
                                               .nunique().reset_index()
grouped247 = df_actual[df_actual['ExpId']==247].groupby('EventName')['DeviceIDHash']\
                                               .nunique().reset_index()
grouped246 = grouped246.sort_values(['DeviceIDHash'], ascending = False)
grouped247 = grouped247.sort_values(['DeviceIDHash'], ascending = False)

display(grouped246)
grouped247
EventName DeviceIDHash
1 MainScreenAppear 2450
2 OffersScreenAppear 1542
0 CartScreenAppear 1266
3 PaymentScreenSuccessful 1200
4 Tutorial 278
Out[47]:
EventName DeviceIDHash
1 MainScreenAppear 2476
2 OffersScreenAppear 1520
0 CartScreenAppear 1238
3 PaymentScreenSuccessful 1158
4 Tutorial 283
In [48]:
# Рассчитаем воронку для каждой группы - сколько пользователей перешло на следующий этап с предыдущего в группе А
grouped246['funnel_246']=round(grouped246["DeviceIDHash"] \
                               / grouped246["DeviceIDHash"].shift(fill_value=2450)*100, 1)
grouped246
Out[48]:
EventName DeviceIDHash funnel_246
1 MainScreenAppear 2450 100.0
2 OffersScreenAppear 1542 62.9
0 CartScreenAppear 1266 82.1
3 PaymentScreenSuccessful 1200 94.8
4 Tutorial 278 23.2
In [49]:
# Какой это % от общего кол-ва уникальных пользователей группы 246
grouped246['%_of_unique_users_in_246'] = round(100*grouped246['DeviceIDHash']\
                                               /unique_users_in_246, 1)
grouped246
Out[49]:
EventName DeviceIDHash funnel_246 %_of_unique_users_in_246
1 MainScreenAppear 2450 100.0 98.6
2 OffersScreenAppear 1542 62.9 62.1
0 CartScreenAppear 1266 82.1 51.0
3 PaymentScreenSuccessful 1200 94.8 48.3
4 Tutorial 278 23.2 11.2
In [50]:
# Строим аналогичную матрицу для группы 247
grouped247['funnel_247']=round(grouped247["DeviceIDHash"] \
                               / grouped247["DeviceIDHash"].shift(fill_value=2476)*100, 1)
grouped247['%_of_unique_users_in_247'] = round(100*grouped247['DeviceIDHash']\
                                               /unique_users_in_247, 1)

grouped247
Out[50]:
EventName DeviceIDHash funnel_247 %_of_unique_users_in_247
1 MainScreenAppear 2476 100.0 98.5
2 OffersScreenAppear 1520 61.4 60.5
0 CartScreenAppear 1238 81.4 49.3
3 PaymentScreenSuccessful 1158 93.5 46.1
4 Tutorial 283 24.4 11.3
In [51]:
#Объединяем матрицы групп 246 и 247
aa1 = grouped246.merge(grouped247, on = 'EventName')
aa1.columns = ['EventName', 'nu_246', 'funnel_246', '%_users_246', \
               'nu_247', 'funnel_247','%_users_247']
aa1 = aa1.reset_index()
aa1 = aa1.drop('index', axis = 1)
aa1
Out[51]:
EventName nu_246 funnel_246 %_users_246 nu_247 funnel_247 %_users_247
0 MainScreenAppear 2450 100.0 98.6 2476 100.0 98.5
1 OffersScreenAppear 1542 62.9 62.1 1520 61.4 60.5
2 CartScreenAppear 1266 82.1 51.0 1238 81.4 49.3
3 PaymentScreenSuccessful 1200 94.8 48.3 1158 93.5 46.1
4 Tutorial 278 23.2 11.2 283 24.4 11.3
In [52]:
# Для теста оставим только данные о кол-ве уникальных пользователей на каждом этапе 
# и их % от общего числа уникальных пользователей группы
aa1_test = aa1.iloc[:, [0, 1, 3, 4, 6]] 
aa1_test 
Out[52]:
EventName nu_246 %_users_246 nu_247 %_users_247
0 MainScreenAppear 2450 98.6 2476 98.5
1 OffersScreenAppear 1542 62.1 1520 60.5
2 CartScreenAppear 1266 51.0 1238 49.3
3 PaymentScreenSuccessful 1200 48.3 1158 46.1
4 Tutorial 278 11.2 283 11.3

Для проверки статистической значимости разницы пропорций пользователей, перешедших на каждый следующий этап, применим proportion z-тест, статистический тест для проверки значимости в отличии в долях пользователей, перешедших на следующий этап воронки. Тест считает количество успехов в анализируемой выборке, учитывая количество наблюдений.

Сформулируем гипотезы:

  • Н0: разница между пропорциями пользователей двух групп, попавших на данный этап, равна нулю (p1 - p2 = 0)
  • Н1: доли пользователей отличаются
In [53]:
for i in range(len(aa1_test['EventName'].values.tolist())):
    count=[aa1_test['nu_246'][i], aa1_test['nu_247'][i]]
    nobs = [unique_users_in_246, unique_users_in_247]
    stat, pval = proportions_ztest(count, nobs)
    print('p-value для сравнения контрольных Групп А и А1 по логу', aa1['EventName'].values.tolist()[i], ':')
    print('{0:.5f}'.format(pval))
p-value для сравнения контрольных Групп А и А1 по логу MainScreenAppear :
0.75706
p-value для сравнения контрольных Групп А и А1 по логу OffersScreenAppear :
0.24810
p-value для сравнения контрольных Групп А и А1 по логу CartScreenAppear :
0.22883
p-value для сравнения контрольных Групп А и А1 по логу PaymentScreenSuccessful :
0.11457
p-value для сравнения контрольных Групп А и А1 по логу Tutorial :
0.93770

Ни на одном этапе между контрольными группами нет статистически значимой разности, поэтому можно сказать, что группы между собой не отличаются. Так как проверка проводилась между двумя контрольными группами, то А/А тест мы можем считать успешным. Настройки теста и сбор данных сработал корректно.

А/В-тест для сравнения экспериментальной (248) группы и двух контрольных (246 и 247)¶

Чтобы провести все нужные тесты, нужно провести сравнение пропорций каждого из 5 этапов для следующих комбинаций:

  • 246 vs. 248 (5 гипотез)
  • 247 vs. 248 (5 гипотез)
  • (246+247) vs. 248 (5 гипотез)

Для этого сначала дополним таблицу с данными для тестирования данными по группе 248, а также по смешанной Группе (246+247)

In [54]:
unique_users_in_248 = df_actual[df_actual['ExpId']==248]['DeviceIDHash'].nunique()

grouped248 = df_actual[df_actual['ExpId']==248].groupby('EventName')['DeviceIDHash']\
                                               .nunique().reset_index()
grouped248 = grouped248.sort_values(['DeviceIDHash'], ascending = False)
grouped248['%_users_248'] = round(100*grouped248['DeviceIDHash']/unique_users_in_248, 1)
grouped248.columns =['EventName', 'nu_248', '%_users_248']
grouped248
Out[54]:
EventName nu_248 %_users_248
1 MainScreenAppear 2493 98.3
2 OffersScreenAppear 1531 60.3
0 CartScreenAppear 1230 48.5
3 PaymentScreenSuccessful 1181 46.6
4 Tutorial 279 11.0
In [55]:
aa1_test = aa1_test.merge(grouped248, on ='EventName')
aa1_test
Out[55]:
EventName nu_246 %_users_246 nu_247 %_users_247 nu_248 %_users_248
0 MainScreenAppear 2450 98.6 2476 98.5 2493 98.3
1 OffersScreenAppear 1542 62.1 1520 60.5 1531 60.3
2 CartScreenAppear 1266 51.0 1238 49.3 1230 48.5
3 PaymentScreenSuccessful 1200 48.3 1158 46.1 1181 46.6
4 Tutorial 278 11.2 283 11.3 279 11.0
In [56]:
#Считаем общее количество уникальных пользователей в 2х контрольных группах
unique_users_in_AA1 = unique_users_in_246 +unique_users_in_247 
unique_users_in_AA1
Out[56]:
4997
In [57]:
aa1_test['nu_AA1'] = aa1_test['nu_246'] + aa1_test['nu_247']
aa1_test['%_users_AA1'] = round(100*aa1_test['nu_AA1'] / unique_users_in_AA1, 1)
aa1_test
Out[57]:
EventName nu_246 %_users_246 nu_247 %_users_247 nu_248 %_users_248 nu_AA1 %_users_AA1
0 MainScreenAppear 2450 98.6 2476 98.5 2493 98.3 4926 98.6
1 OffersScreenAppear 1542 62.1 1520 60.5 1531 60.3 3062 61.3
2 CartScreenAppear 1266 51.0 1238 49.3 1230 48.5 2504 50.1
3 PaymentScreenSuccessful 1200 48.3 1158 46.1 1181 46.6 2358 47.2
4 Tutorial 278 11.2 283 11.3 279 11.0 561 11.2

A/B тест для групп 246 и 248¶

Сформулируем гипотезы:

  • Н0: разница между пропорциями пользователей двух групп, попавших на данный этап, равна нулю (p1 - p2 = 0)
  • Н1: доли пользователей отличаются
In [58]:
for i in range(len(aa1_test['EventName'].values.tolist())):
    count=[aa1_test['nu_246'][i], aa1_test['nu_248'][i]]
    nobs = [unique_users_in_246, unique_users_in_248]
    stat, pval = proportions_ztest(count, nobs)
    print('p-value для сравнения контрольных Групп 246 и 248 по логу',aa1_test['EventName'].values.tolist()[i], ':')
    print('{0:.5f}'.format(pval))
p-value для сравнения контрольных Групп 246 и 248 по логу MainScreenAppear :
0.29497
p-value для сравнения контрольных Групп 246 и 248 по логу OffersScreenAppear :
0.20836
p-value для сравнения контрольных Групп 246 и 248 по логу CartScreenAppear :
0.07843
p-value для сравнения контрольных Групп 246 и 248 по логу PaymentScreenSuccessful :
0.21226
p-value для сравнения контрольных Групп 246 и 248 по логу Tutorial :
0.82643

Ни на одном этапе между контрольной группой 246 и экспериментальной 248 нет статистически значимой разности, поэтому можно сказать, что группы между собой не отличаются

A/B тест для групп 247 и 248¶

Сформулируем гипотезы:

  • Н0: разница между пропорциями пользователей двух групп, попавших на данный этап, равна нулю (p1 - p2 = 0)
  • Н1: доли пользователей отличаются
In [59]:
for i in range(len(aa1_test['EventName'].values.tolist())):
    count=[aa1_test['nu_247'][i], aa1_test['nu_248'][i]]
    nobs = [unique_users_in_247, unique_users_in_248]
    stat, pval = proportions_ztest(count, nobs)
    print('p-value для сравнения контрольных Групп 247 и 248 по логу', aa1_test['EventName'].values.tolist()[i], ':')
    print('{0:.5f}'.format(pval))
p-value для сравнения контрольных Групп 247 и 248 по логу MainScreenAppear :
0.45871
p-value для сравнения контрольных Групп 247 и 248 по логу OffersScreenAppear :
0.91978
p-value для сравнения контрольных Групп 247 и 248 по логу CartScreenAppear :
0.57862
p-value для сравнения контрольных Групп 247 и 248 по логу PaymentScreenSuccessful :
0.73734
p-value для сравнения контрольных Групп 247 и 248 по логу Tutorial :
0.76532

Ни на одном этапе между контрольной группой 247 и экспериментальной 248 нет статистически значимой разности, поэтому можно сказать, что группы между собой не отличаются

A/B тест для групп (246+247) и 248¶

Сформулируем гипотезы:

  • Н0: разница между пропорциями пользователей двух групп, попавших на данный этап, равна нулю (p1 - p2 = 0)
  • Н1: доли пользователей отличаются
In [60]:
for i in range(len(aa1_test['EventName'].values.tolist())):
    count=[aa1_test['nu_AA1'][i], aa1_test['nu_248'][i]]
    nobs = [unique_users_in_AA1, unique_users_in_248]
    stat, pval = proportions_ztest(count, nobs)
    print('p-value для сравнения групп (246+247) и 248 по логу', aa1_test['EventName'].values.tolist()[i], ':')
    print('{0:.5f}'.format(pval))
p-value для сравнения групп (246+247) и 248 по логу MainScreenAppear :
0.29425
p-value для сравнения групп (246+247) и 248 по логу OffersScreenAppear :
0.43426
p-value для сравнения групп (246+247) и 248 по логу CartScreenAppear :
0.18176
p-value для сравнения групп (246+247) и 248 по логу PaymentScreenSuccessful :
0.60043
p-value для сравнения групп (246+247) и 248 по логу Tutorial :
0.76486

Как и в других тестах, статистической значимости в разнице значений пропорций не обнаружено, поэтому можно сказать, что группы между собой не отличаются

Определение скорректированного уровня значимости с учетом корректировки на множественную проверку гипотез.¶

Уровень значимости при тестировании гипотез был выбран на уровне альфа = 0.05. Всего было проведено 20 проверок гипотез. Однако при множественной проверке гипотез с каждой новой проверкой гипотезы растёт групповая вероятность ошибки первого рода (FWER). Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, применяют разные методы корректировки уровня значимости для уменьшения FWER (в т.ч. метод Бонферрони, метод Холма или Шидака).

Воспользуемся попровкой Шидака, формула которой выглядит так: $$ \ a_1 = a_2 = a_m = 1- {(1-a)}^1/m $$

In [61]:
alpha = 0.05
m=20
1/m
alpha_adj=round(1-(1-alpha)**(1/m), 4)
alpha_adj
Out[61]:
0.0026

Какие выводы можно сделать имея скорректированную альфу? Какой уровень значимости стоит применить? Стоит ли изменить уровень значимости?

  • alpha_adj=0.0026 должна была бы подвергнуть сомнению те результаты наших тестов, где p-value получилось в интревале от 0.05 до alpha_adj - то есть там, где мы могли неправильно отвергнуть "Но", то есть допустить ошибку первого рода /получить ложнопозитивный результат статистического теста.
  • иными словами в реальности различий между сравниваемыми группами нет, но тест показал p-value меньше уровня значимости альфа, на основании чего и была отвергнута Но (уровень значимости (альфа) - и есть вероятность ошибки первого рода).
  • глядя на результаты p-value, полученных в результате проведенных нами тестов, можно увидеть, что почти все они намного больше установленного нами уровня альфа (все гороздо больше 0.05).
  • поэтому изменение уровня значимости с 0.05 до скорректированной на множественную проверку альфу наши выводы не меняет, т.е. отличия в долях пользователей, совершивших определенные события, не являются статистически значимыми.
  • так как же быть с предложением об изменении шрифтов приложения? Раз мы не установили статистически значимой разницы в конверсиях, то можно заключить, что шрифты не влияют на активность пользователей. Соответственно, можно их поменять или оставить как есть, никаких негативнх последствий не будет. Но если предположить, например, что смена шрифтов это первый шаг к апгрейду приложения, тогда почему бы их не поменять, раз это такая возможность постепенно и безболезнено вносить изменения и придавать приложению более современный и стильный дизайн.

Выводы¶

Логика проведения и итоги исследования:

  • было проанализировано поведение пользователей мобильного приложения и сделаны предположения относительно наиболее вероятной последовательности этапов его использования
  • на их основании была построена пользовательская воронка, показавшая что:
    • порядок этапов следующий: MainScreenAppear, OffersScreenAppear, CartScreenAppear, PaymentScreenSucc
    • Этап Tutorial нерелевантен при построении воронки, так как на него можно попасть абсолютно в любой момент, от последовательности прохождения других этапов он не зависит
    • наибольшее кол-во пользователей бывают на этапе MainScreenAppear
    • только 48% пользователей доходят до последнего этапа PaymentScreenSucc
    • самые большие потери пользователей (-39%) случаются при переходе с этапа MainScreenAppear на этап OffersScreenAppear
  • для изучения гипотезы о целесообразности изменения шрифтов приложения данные были подготовлены с учетом требований к проведению А/А и А/В-тестов:
    • пользователей разбили на 3 группы: 2 контрольные (для А/А теста) и экспериментальную (для А/В теста)
    • были проведены А/А тесты с целью подтверждения корректности подходов к распределению пользователей по группам (проверена сопоставимость числа пользователей в группах, отсутствие пересечений пользователей, а также отсутствие статистической значимости в поведении пользователей этих 2 групп на разных этапах использования приложения относительно друг друга.
  • это позволило перейти к А/Б-тестированию, где [как и на предыдущем этапе А/А-теста] статистически значимые различия между поведением пользователей разных групп на основных этапах пользования приложением обнаружены не были.
  • всего было проведено 20 проверок гипотез (5 этапов приложения для каждой пары групп: А - А1, А - В, А1 - В и (А+А1) - В).
  • корректировка уровня значимости на множественную проверку никаких изменений в логику выводов не внесла, т.е. изменение шрифтов никак не влияет на поведение пользователей.

Анализ результатов позволяет предложить следующие рекомендации:

  • уделить особое внимание работе над увеличением конверсии при переходе с этапа MainScreenAppear на этап OffersScreenAppear
  • с учетом отсутствия статистически значимой разности между контрольными и экспериментальными группами, само по себе изменение шрифтов приложения негативным образом на конверсии не отразится. Поэтому решение об их смене может приниматься руководством исходя их иных факторов, как например, доступность финансовых/временных ресурсов.